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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -2
- data/README.md +53 -23
- data/app/controllers/studio/email_images_controller.rb +4 -4
- data/app/controllers/studio/emails_controller.rb +55 -11
- data/app/mailers/user_mailer.rb +1 -1
- data/app/models/image_cache.rb +1 -1
- data/app/services/studio/email_catalog.rb +450 -0
- data/app/services/studio/email_image.rb +56 -317
- data/app/views/layouts/branded_mailer.html.erb +1 -1
- data/app/views/studio/email_images/index.html.erb +2 -2
- data/app/views/studio/emails/_row.html.erb +8 -3
- data/app/views/studio/emails/index.html.erb +4 -4
- data/app/views/studio/emails/show.html.erb +91 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +10 -3
- metadata +3 -1
|
@@ -1,329 +1,68 @@
|
|
|
1
1
|
module Studio
|
|
2
|
-
#
|
|
3
|
-
#
|
|
2
|
+
# DEPRECATED NAME — this is Studio::EmailCatalog now. Every method here just
|
|
3
|
+
# forwards; there is no behavior in this file.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
18
|
-
#
|
|
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
|
-
#
|
|
23
|
-
#
|
|
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 =
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
|
|
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::
|
|
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::
|
|
41
|
-
<% inherited = current_url.present? && Studio::
|
|
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::
|
|
9
|
-
banner_url = Studio::
|
|
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
|
-
|
|
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::
|
|
24
|
-
max_width = Studio::
|
|
25
|
-
app_owned = @entries.count { |entry| Studio::
|
|
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::
|
|
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>
|
data/lib/studio/version.rb
CHANGED
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",
|
|
562
|
-
|
|
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
|
-
|
|
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.
|
|
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
|