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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +210 -0
- data/README.md +199 -12
- 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 +42 -7
- data/app/controllers/studio/emails_controller.rb +129 -0
- data/app/mailers/user_mailer.rb +4 -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 +57 -74
- data/app/views/layouts/branded_mailer.html.erb +1 -1
- data/app/views/studio/email_images/index.html.erb +30 -1
- data/app/views/studio/emails/_row.html.erb +107 -0
- data/app/views/studio/emails/index.html.erb +116 -0
- data/app/views/studio/emails/show.html.erb +91 -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 +75 -4
- metadata +10 -2
|
@@ -1,85 +1,68 @@
|
|
|
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
|
+
# DEPRECATED NAME — this is Studio::EmailCatalog now. Every method here just
|
|
3
|
+
# forwards; there is no behavior in this file.
|
|
7
4
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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 =
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def
|
|
30
|
-
|
|
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
|
-
#
|
|
63
|
-
#
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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)
|
|
69
56
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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::
|
|
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
|
-
|
|
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 · 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::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
|
-
|
|
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(
|
|
221
|
-
Alpine.store(
|
|
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(
|
|
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 = "";
|