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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +222 -0
- data/README.md +157 -0
- data/app/assets/images/emails/email-change-confirmation.png +0 -0
- data/app/assets/images/emails/magic-link.png +0 -0
- data/app/controllers/studio/email_images_controller.rb +38 -3
- data/app/controllers/studio/emails_controller.rb +85 -0
- data/app/controllers/studio/local_reviews_controller.rb +85 -1
- data/app/mailers/user_mailer.rb +4 -1
- data/app/services/studio/email_image.rb +277 -33
- data/app/views/studio/email_images/index.html.erb +30 -1
- data/app/views/studio/emails/_row.html.erb +102 -0
- data/app/views/studio/emails/index.html.erb +116 -0
- data/app/views/studio/modals/_image_upload.html.erb +38 -7
- data/app/views/studio/modals/_scoped_host.html.erb +195 -0
- data/lib/studio/engine.rb +20 -0
- data/lib/studio/s3.rb +44 -8
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +98 -4
- metadata +8 -2
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
3
|
-
#
|
|
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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
119
|
+
registry.transform_values(&:label)
|
|
23
120
|
end
|
|
24
121
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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:
|
|
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
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
47
|
-
# Returns the ::ImageCache. Raises on failure after
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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:
|
|
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:
|
|
55
|
-
delete_object(previous) if previous.present? && previous !=
|
|
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(
|
|
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
|
-
|
|
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 · 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 ·
|
|
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 %>
|