studio-engine 0.36.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,85 +1,68 @@
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
+ # DEPRECATED NAME this is Studio::EmailCatalog now. Every method here just
3
+ # forwards; there is no behavior in this file.
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
+ # The METHOD surface is kept complete, for two reasons:
6
+ #
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.
14
+ #
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.
22
+ #
23
+ # Delete it once no consumer's main names it — the same staged retirement
24
+ # /admin/email_images is on.
25
+ #
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.
11
28
  module EmailImage
12
- PURPOSE = "email_banner".freeze
13
-
14
- # variant => human label, in admin display order.
15
- VARIANTS = {
16
- "magic_link" => "Magic-link sign-in"
17
- }.freeze
29
+ PURPOSE = EmailCatalog::PURPOSE
30
+ ASPECT_RATIO = EmailCatalog::ASPECT_RATIO
31
+ MAX_WIDTH = EmailCatalog::MAX_WIDTH
18
32
 
19
33
  module_function
20
34
 
21
- def variants
22
- VARIANTS
23
- end
24
-
25
- def label(variant)
26
- VARIANTS[variant.to_s] || variant.to_s.humanize
27
- end
28
-
29
- def known?(variant)
30
- VARIANTS.key?(variant.to_s)
31
- end
32
-
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)
36
- return nil unless table_ready?
37
-
38
- ::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: variant.to_s)
39
- end
40
-
41
- # Permanent public S3 url for the current banner, or nil.
42
- def url(variant)
43
- record(variant)&.url
44
- end
45
-
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,
51
- cache_control: "public, max-age=300")
52
- record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: variant.to_s)
53
- previous = record.s3_key
54
- record.update!(s3_key: key)
55
- delete_object(previous) if previous.present? && previous != key
56
- record
57
- rescue StandardError
58
- delete_object(key)
59
- raise
60
- 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!
61
45
 
62
- # Reference ImageCache directly so Zeitwerk autoloads itdefined?() does NOT
63
- # trigger autoload, so it would read "undefined" for a not-yet-loaded const.
64
- def table_ready?
65
- ::ImageCache.table_exists?
66
- rescue NameError, ActiveRecord::ActiveRecordError
67
- false
68
- end
46
+ # Image resolution. `url` is the pre-registry contractthis 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)
69
56
 
70
- def ext_for(content_type)
71
- case content_type.to_s
72
- when %r{png} then ".png"
73
- when %r{jpe?g} then ".jpg"
74
- when %r{webp} then ".webp"
75
- else ".png"
76
- end
77
- end
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?
78
62
 
79
- def delete_object(key)
80
- Studio::S3.delete(key: key)
81
- rescue StandardError
82
- nil
83
- end
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)
84
67
  end
85
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
@@ -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::EmailCatalog.preview_url(variant) %>
41
+ <% inherited = current_url.present? && Studio::EmailCatalog.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,107 @@
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::EmailCatalog.source(entry.key)
9
+ banner_url = Studio::EmailCatalog.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
+ <%# 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 %>
68
+ <% if entry.description.present? %>
69
+ <p class="text-sm text-body mt-0.5 max-w-md"><%= entry.description %></p>
70
+ <% end %>
71
+ <p class="font-mono text-2xs text-muted mt-1"><%= entry.key %></p>
72
+ </td>
73
+
74
+ <%# whitespace-nowrap keeps the pill a pill — "<App>'s own" is long enough to
75
+ wrap inside a bordered badge, which reads as a broken box next to the
76
+ short "Inherited default". %>
77
+ <td class="px-4 py-4">
78
+ <span class="<%= badge_class %> whitespace-nowrap"><%= badge_label %></span>
79
+ <p class="text-2xs text-muted mt-1.5 max-w-[12rem]"><%= badge_note %></p>
80
+ </td>
81
+
82
+ <% if uploads_available %>
83
+ <td class="px-4 py-4 text-right whitespace-nowrap">
84
+ <button type="button" @click="open()" class="btn btn-outline btn-sm inline-flex items-center gap-1.5">
85
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
86
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
87
+ 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"/>
88
+ </svg>
89
+ <%= source == :app ? "Replace" : "Upload" %>
90
+ </button>
91
+
92
+ <%# Only an APP-OWNED image can be reverted — there is nothing to drop when
93
+ the row is already showing the inherited default. %>
94
+ <% if source == :app %>
95
+ <%= button_to "Revert", admin_email_path(entry.key), method: :delete,
96
+ class: "btn btn-neutral btn-sm mt-1.5 w-full",
97
+ form: { data: { turbo_confirm: "Drop #{Studio.app_name}'s own #{entry.label} banner and go back to the shared default?" } } %>
98
+ <% end %>
99
+
100
+ <%# The cropper drops its Blob into this hidden input and submits. %>
101
+ <%= form_with url: admin_email_path(entry.key), method: :patch,
102
+ html: { multipart: true, id: form_id, "x-ref": "form" } do %>
103
+ <%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
104
+ <% end %>
105
+ </td>
106
+ <% end %>
107
+ </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::EmailCatalog::ASPECT_RATIO
24
+ max_width = Studio::EmailCatalog::MAX_WIDTH
25
+ app_owned = @entries.count { |entry| Studio::EmailCatalog.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::EmailCatalog.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 %>
@@ -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>
@@ -58,6 +58,9 @@
58
58
  if (props.maxHeight) this.maxHeight = props.maxHeight;
59
59
  if (typeof props.transparent === "boolean") this.transparent = props.transparent;
60
60
  if (props.dispatch) this.dispatch = true;
61
+ // Carried through untouched and echoed back on confirm, so the host that
62
+ // OPENED the cropper is the only one that acts on the result.
63
+ this.owner = props.owner || null;
61
64
  if (props.autoCropArea) this.autoCropArea = props.autoCropArea;
62
65
  if (props.imageUrl) {
63
66
  this.fromParent = true;
@@ -139,7 +142,15 @@
139
142
  var canvas = this.cropper.getCroppedCanvas(canvasOpts);
140
143
  canvas.toBlob(function (blob) {
141
144
  try {
142
- window.dispatchEvent(new CustomEvent("crop-photo-confirmed", { detail: { blob: blob } }));
145
+ // OWNER RIDES WITH THE BLOB. `crop-photo-confirmed` is a WINDOW event, so
146
+ // every imageUploadHost on the page hears it — and /admin/emails mounts
147
+ // one host PER ROW. Without an owner, one confirmed crop PATCHed every
148
+ // row's banner with the same image, destroying any app-owned banner
149
+ // already there; the FIRST upload an admin ever performed hit it, because
150
+ // the engine pre-registers two emails. `owner` is the token the opening
151
+ // host stamped on the crop props, echoed back untouched.
152
+ window.dispatchEvent(new CustomEvent("crop-photo-confirmed",
153
+ { detail: { blob: blob, owner: self.owner || null } }));
143
154
  } catch (_) {}
144
155
  if (self.cropper) { self.cropper.destroy(); self.cropper = null; }
145
156
  // dispatch mode: the opener's host owns the post-confirm flow
@@ -162,10 +173,11 @@
162
173
  // Swaps in the 'saving' card while the form uploads, then closes it (held
163
174
  // >= ~450ms so it doesn't flash) and toasts on completion. opts: { saving,
164
175
  // success, successMessage, failure, failureMessage, dismissible (default
165
- // false), toast (default true) }.
176
+ // false), toast (default true), store (Alpine store name, default "modals" —
177
+ // pass a page-scoped host's name, e.g. "emailModals") }.
166
178
  window.submitFormWithProgress = function (form, opts) {
167
179
  opts = opts || {};
168
- var store = window.Alpine && Alpine.store("modals");
180
+ var store = window.Alpine && Alpine.store(opts.store || "modals");
169
181
  var hold = (window.StudioModals && window.StudioModals.holdAtLeast)
170
182
  ? window.StudioModals.holdAtLeast(450)
171
183
  : { then: function (cb) { cb(); } };
@@ -199,9 +211,14 @@
199
211
  // it into the host's own hidden form input and submits immediately with a
200
212
  // loading card + toast (submitFormWithProgress). opts = crop config
201
213
  // (aspectRatio, maxWidth, maxHeight, transparent, autoCropArea) + save copy
202
- // (saving, success, successMessage, failure, dismissible, toast, filename).
214
+ // (saving, success, successMessage, failure, dismissible, toast, filename) +
215
+ // store (Alpine store name, default "modals"). Pass `store` when the crop and
216
+ // saving modals are mounted on a PAGE-SCOPED host rather than the app's shared
217
+ // one — /admin/emails does, so the uploader works even in a host app that
218
+ // renders no shared modal host at all.
203
219
  window.imageUploadHost = function (opts) {
204
220
  opts = opts || {};
221
+ var storeName = opts.store || "modals";
205
222
  function cropProps(extra) {
206
223
  var p = {
207
224
  aspectRatio: opts.aspectRatio || 1,
@@ -214,11 +231,25 @@
214
231
  if (extra) { for (var k in extra) { p[k] = extra[k]; } }
215
232
  return p;
216
233
  }
234
+ // A token unique to THIS host instance. Many hosts can be mounted on one page
235
+ // (one per row on /admin/emails) and they all hear the same window event, so a
236
+ // confirm has to say WHICH host opened the cropper.
237
+ var ownerId = "iuh-" + (window.__studioImageUploadHostSeq = (window.__studioImageUploadHostSeq || 0) + 1);
238
+
217
239
  return {
240
+ ownerId: ownerId,
218
241
  // Modal-as-picker: the crop modal itself is the file picker / drop target.
219
242
  open() {
220
- if (!window.Alpine || !Alpine.store("modals")) return;
221
- Alpine.store("modals").open("crop-photo", cropProps());
243
+ if (!window.Alpine || !Alpine.store(storeName)) return;
244
+ Alpine.store(storeName).open("crop-photo", cropProps({ owner: ownerId }));
245
+ },
246
+ // The window-event guard: a confirm addressed to a DIFFERENT host is not ours
247
+ // to act on. An owner-LESS confirm still applies, so every existing
248
+ // single-host page keeps working unchanged.
249
+ onCropConfirmed(detail) {
250
+ if (!detail) return;
251
+ if (detail.owner && detail.owner !== ownerId) return;
252
+ this.applyCrop(detail.blob);
222
253
  },
223
254
  // Native picker: read the chosen image, then hand it to the modal.
224
255
  onFileSelected(event) {
@@ -226,7 +257,7 @@
226
257
  if (!file) return;
227
258
  var reader = new FileReader();
228
259
  reader.onload = function (e) {
229
- Alpine.store("modals").open("crop-photo", cropProps({ imageUrl: e.target.result }));
260
+ Alpine.store(storeName).open("crop-photo", cropProps({ imageUrl: e.target.result, owner: ownerId }));
230
261
  };
231
262
  reader.readAsDataURL(file);
232
263
  event.target.value = "";